/*******************************************************************************
 * Copyright (c) 2012-2016 Codenvy, S.A.
 * All rights reserved. This program and the accompanying materials
 * are made available under the terms of the Eclipse Public License v1.0
 * which accompanies this distribution, and is available at
 * http://www.eclipse.org/legal/epl-v10.html
 *
 * Contributors:
 *   Codenvy, S.A. - initial API and implementation
 *******************************************************************************/
package org.eclipse.che.security.oauth;

import com.google.api.client.auth.oauth2.AuthorizationCodeFlow;
import com.google.api.client.auth.oauth2.AuthorizationCodeRequestUrl;
import com.google.api.client.auth.oauth2.AuthorizationCodeResponseUrl;
import com.google.api.client.auth.oauth2.BearerToken;
import com.google.api.client.auth.oauth2.ClientParametersAuthentication;
import com.google.api.client.auth.oauth2.Credential;
import com.google.api.client.auth.oauth2.TokenResponse;
import com.google.api.client.http.GenericUrl;
import com.google.api.client.http.javanet.NetHttpTransport;
import com.google.api.client.json.jackson2.JacksonFactory;
import com.google.api.client.util.store.MemoryDataStoreFactory;

import org.eclipse.che.api.auth.shared.dto.OAuthToken;
import org.eclipse.che.commons.json.JsonHelper;
import org.eclipse.che.commons.json.JsonParseException;
import org.eclipse.che.security.oauth.shared.User;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.ws.rs.core.MediaType;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URL;
import java.net.URLDecoder;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;

import static org.eclipse.che.dto.server.DtoFactory.newDto;

/** Authentication service which allow get access token from OAuth provider site. */
public abstract class OAuthAuthenticator {
    private static final Logger LOG = LoggerFactory.getLogger(OAuthAuthenticator.class);

    private   AuthorizationCodeFlow flow;
    protected Map<Pattern, String>  redirectUrisMap;

    /**
     * @see {@link #configure(String, String, String[], String, String, MemoryDataStoreFactory, List)}
     */
    protected void configure(String clientId, String clientSecret, String[] redirectUris, String authUri, String tokenUri,
                             MemoryDataStoreFactory dataStoreFactory) throws IOException {
        configure(clientId, clientSecret, redirectUris, authUri, tokenUri, dataStoreFactory, Collections.emptyList());
    }

    /**
     * This method should be invoked by child class for initialization default instance of {@link AuthorizationCodeFlow}
     * that will be used for authorization
     */
    protected void configure(String clientId, String clientSecret, String[] redirectUris, String authUri, String tokenUri,
                             MemoryDataStoreFactory dataStoreFactory, List<String> scopes) throws IOException {
        final AuthorizationCodeFlow authorizationFlow = new AuthorizationCodeFlow.Builder(BearerToken.authorizationHeaderAccessMethod(),
                                                                                          new NetHttpTransport(),
                                                                                          new JacksonFactory(),
                                                                                          new GenericUrl(tokenUri),
                                                                                          new ClientParametersAuthentication(
                                                                                                  clientId,
                                                                                                  clientSecret),
                                                                                          clientId,
                                                                                          authUri).setDataStoreFactory(dataStoreFactory)
                                                                                                  .setScopes(scopes)
                                                                                                  .build();

        LOG.debug("clientId={}, clientSecret={}, redirectUris={} , authUri={}, tokenUri={}, dataStoreFactory={}",
                  clientId,
                  clientSecret,
                  redirectUris,
                  authUri,
                  tokenUri,
                  dataStoreFactory);

        configure(authorizationFlow, Arrays.asList(redirectUris));
    }

    /**
     * This method should be invoked by child class for setting instance of {@link AuthorizationCodeFlow}
     * that will be used for authorization
     */
    protected void configure(AuthorizationCodeFlow flow, List<String> redirectUris) {
        this.flow = flow;
        this.redirectUrisMap = new HashMap<>(redirectUris.size());
        for (String uri : redirectUris) {
            // Redirect URI may be in form urn:ietf:wg:oauth:2.0:oob os use java.net.URI instead of java.net.URL
            this.redirectUrisMap.put(Pattern.compile("([a-z0-9\\-]+\\.)?" + URI.create(uri).getHost()), uri);
        }
    }

    /**
     * Create authentication URL.
     *
     * @param requestUrl
     *         URL of current HTTP request. This parameter required to be able determine URL for redirection after
     *         authentication. If URL contains query parameters they will be copy to 'state' parameter and returned to
     *         callback method.
     * @param scopes
     *         specify exactly what type of access needed
     * @return URL for authentication
     */
    public String getAuthenticateUrl(URL requestUrl, List<String> scopes) throws OAuthAuthenticationException {
        if (!isConfigured()) {
            throw new OAuthAuthenticationException("Authenticator is not configured");
        }

        AuthorizationCodeRequestUrl url = flow.newAuthorizationUrl().setRedirectUri(findRedirectUrl(requestUrl))
                                              .setScopes(scopes);
        url.setState(prepareState(requestUrl));
        return url.build();
    }

    protected String prepareState(URL requestUrl) {
        StringBuilder state = new StringBuilder();
        String query = requestUrl.getQuery();
        if (query != null) {
            if (state.length() > 0) {
                state.append('&');
            }
            state.append(query);
        }
        return state.toString();
    }

    protected String findRedirectUrl(URL requestUrl) {
        final String requestHost = requestUrl.getHost();
        for (Map.Entry<Pattern, String> e : redirectUrisMap.entrySet()) {
            if (e.getKey().matcher(requestHost).matches()) {
                return e.getValue();
            }
        }
        return null; // TODO : throw exception instead of return null ???
    }

    /**
     * Process callback request.
     *
     * @param requestUrl
     *         request URI. URI should contain authorization code generated by authorization server
     * @param scopes
     *         specify exactly what type of access needed. This list must be exactly the same as list passed to the method
     *         {@link #getAuthenticateUrl(URL, java.util.List)}
     * @return id of authenticated user
     * @throws OAuthAuthenticationException
     *         if authentication failed or <code>requestUrl</code> does not contain required parameters, e.g. 'code'
     */
    public String callback(URL requestUrl, List<String> scopes) throws OAuthAuthenticationException {
        if (!isConfigured()) {
            throw new OAuthAuthenticationException("Authenticator is not configured");
        }

        AuthorizationCodeResponseUrl authorizationCodeResponseUrl = new AuthorizationCodeResponseUrl(requestUrl.toString());
        final String error = authorizationCodeResponseUrl.getError();
        if (error != null) {
            throw new OAuthAuthenticationException("Authentication failed: " + error);
        }
        final String code = authorizationCodeResponseUrl.getCode();
        if (code == null) {
            throw new OAuthAuthenticationException("Missing authorization code. ");
        }

        try {
            TokenResponse tokenResponse = flow.newTokenRequest(code).setRequestInitializer(request -> {
                if (request.getParser() == null) {
                    request.setParser(flow.getJsonFactory().createJsonObjectParser());
                }
                request.getHeaders().setAccept(MediaType.APPLICATION_JSON);
            }).setRedirectUri(findRedirectUrl(requestUrl)).setScopes(scopes).execute();
            String userId = getUserFromUrl(authorizationCodeResponseUrl);
            if (userId == null) {
                userId = getUser(newDto(OAuthToken.class).withToken(tokenResponse.getAccessToken())).getId();
            }
            flow.createAndStoreCredential(tokenResponse, userId);
            return userId;
        } catch (IOException ioe) {
            throw new OAuthAuthenticationException(ioe.getMessage());
        }
    }

    /**
     * Get user info.
     *
     * @param accessToken
     *         oauth access token
     * @return user info
     * @throws OAuthAuthenticationException
     *         if fail to get user info
     */
    public abstract User getUser(OAuthToken accessToken) throws OAuthAuthenticationException;

    /**
     * Get the name of OAuth provider supported by current implementation.
     *
     * @return oauth provider name
     */
    public abstract String getOAuthProvider();

    private String getUserFromUrl(AuthorizationCodeResponseUrl authorizationCodeResponseUrl) throws IOException {
        String state = authorizationCodeResponseUrl.getState();
        if (!(state == null || state.isEmpty())) {
            String decoded = URLDecoder.decode(state, "UTF-8");
            String[] items = decoded.split("&");
            for (String str : items) {
                if (str.startsWith("userId=")) {
                    return str.substring(7, str.length());
                }
            }
        }
        return null;
    }

    protected <O> O getJson(String getUserUrl, Class<O> userClass) throws OAuthAuthenticationException {
        HttpURLConnection urlConnection = null;
        InputStream urlInputStream = null;

        try {
            urlConnection = (HttpURLConnection)new URL(getUserUrl).openConnection();
            urlInputStream = urlConnection.getInputStream();
            return JsonHelper.fromJson(urlInputStream, userClass, null);
        } catch (JsonParseException | IOException e) {
            throw new OAuthAuthenticationException(e.getMessage(), e);
        } finally {
            if (urlInputStream != null) {
                try {
                    urlInputStream.close();
                } catch (IOException ignored) {
                }
            }

            if (urlConnection != null) {
                urlConnection.disconnect();
            }
        }
    }

    /**
     * Return authorization token by userId.
     * <p/>
     * WARN!!!. DO not use it directly.
     *
     * @param userId
     *         user identifier
     * @return token value or {@code null}. When user have valid token then it will be returned,
     * when user have expired token and it can be refreshed then refreshed value will be returned,
     * when none token found for user then {@code null} will be returned,
     * when user have expired token and it can't be refreshed then {@code null} will be returned
     * @throws IOException
     *         when error occurs during token loading
     * @see org.eclipse.che.api.auth.oauth.OAuthTokenProvider#getToken(String, String)
     */
    public OAuthToken getToken(String userId) throws IOException {
        if (!isConfigured()) {
            throw new IOException("Authenticator is not configured");
        }
        Credential credential = flow.loadCredential(userId);
        if (credential == null) {
            return null;
        }
        final Long expirationTime = credential.getExpiresInSeconds();
        if (expirationTime != null && expirationTime < 0) {
            boolean tokenRefreshed;
            try {
                tokenRefreshed = credential.refreshToken();
            } catch (IOException ioEx) {
                tokenRefreshed = false;
            }
            if (tokenRefreshed) {
                credential = flow.loadCredential(userId);
            } else {
                // if token is not refreshed then old value should be invalidated
                // and null result should be returned
                try {
                    invalidateToken(userId);
                } catch (IOException ignored) {
                }
                return null;
            }
        }
        return newDto(OAuthToken.class).withToken(credential.getAccessToken());
    }

    /**
     * Invalidate OAuth token for specified user.
     *
     * @param userId
     *         user
     * @return <code>true</code> if OAuth token invalidated and <code>false</code> otherwise, e.g. if user does not have
     * token yet
     */
    public boolean invalidateToken(String userId) throws IOException {
        Credential credential = flow.loadCredential(userId);
        if (credential != null) {
            flow.getCredentialDataStore().delete(userId);
            return true;
        }
        return false;
    }

    /**
     * Checks configuring of authenticator
     *
     * @return true only if authenticator have valid configuration data and it is able to authorize
     * otherwise returns false
     */
    public boolean isConfigured() {
        return flow != null;
    }
}
